fix: sync-main-to-experimental action correct base branch - #479
fix: sync-main-to-experimental action correct base branch#479pravusjif wants to merge 5 commits into
Conversation
Test this pull request
|
decentraland-bot
left a comment
There was a problem hiding this comment.
Review: fix: sync-main-to-experimental action correct base branch
Files changed: 1 (+67 −5) — .github/workflows/sync-main-to-experimental.yml
CI: All checks passing ✅
ADR-6: PR title (fix: …) and branch (fix/sync-main-to-experimental-action) follow semantic conventions ✅
Problem & Fix Assessment
The old workflow was fundamentally broken: git checkout -B chore/sync without specifying a start point created chore/sync at main's HEAD, discarding all experimental-only content. The new approach correctly builds chore/sync as experimental + main — experimental-only protos survive the sync.
Analysis
✅ Architecture & Merge Strategy
- Correct base: branching from
origin/experimental(or reusingorigin/chore/syncwhen a PR is already open) ensures experimental-only content is preserved. - Uniform merge loop: the
for REF in origin/experimental origin/mainloop handles both fresh and reuse paths cleanly. When starting fromorigin/experimental, the first merge is a no-op ("Already up to date") — correct and harmless. - Conflict handling:
merge --abort || true+::error::+exit 1fails cleanly instead of pushing broken state. - No-op detection:
git diff --quiet origin/experimental HEADcorrectly skips the push and PR creation when experimental already contains everything in main.
✅ Race Condition Prevention
- Concurrency group (
cancel-in-progress: false) ensures two pushes tomainqueue instead of racing on the push tochore/sync. --force-with-leaseinstead of--forceguards against overwriting manual conflict resolutions pushed between the fetch and the push. Significant safety improvement over the old--force.
✅ Edge Cases Verified
| Scenario | Behavior |
|---|---|
| First run, no existing PR | Branches from experimental, merges main, creates PR |
Existing PR open, chore/sync exists |
Reuses branch, preserves manual conflict resolution |
PR was closed but chore/sync still exists |
PR_NUMBER is empty → falls back to origin/experimental (fresh start) |
| Nothing to sync | Prints message, sets changed=false, skips push + PR |
| Merge conflict | Aborts merge, emits ::error::, exits 1 |
Manual push to chore/sync during run |
--force-with-lease rejects the push (correct — protects manual work) |
workflow_dispatch trigger |
Works identically to push trigger |
✅ Security
- No secrets exposure: only
GITHUB_TOKEN(automatically provisioned). - Minimal permissions:
contents: write(push),pull-requests: write(PR create/comment),issues: write(needed bygh pr comment/--labelwhich internally hit the Issues API). All justified. - No injection vectors: no untrusted user input flows into shell commands. The workflow triggers are
push(tomain) andworkflow_dispatch— both trusted. - No hardcoded credentials or sensitive data in logs.
✅ Shell Scripting Quality
set -euo pipefailon all run blocks.- Variables properly quoted (
"$PR_NUMBER","$BASE"). // emptyin jq correctly produces empty string (notnull) when no PR exists.git rev-parse --verify --quietsafely checks branch existence without error output.- Step outputs via
$GITHUB_OUTPUT(modern GitHub Actions pattern, not deprecatedset-output).
ℹ️ Why the git identity is required
The old workflow never created commits — it only did checkout -B and push. The new workflow creates merge commits (git merge --no-edit -m "...") and git requires an author identity for that. The github-actions[bot] user with email 41898282+github-actions[bot]@users.noreply.github.com is the standard GitHub Actions bot identity (41898282 is the bot's numeric user ID). This is the correct and conventional approach.
✅ Consumer Impact
This change modifies an internal CI workflow only. No public API surfaces, exported packages, or schemas are affected. No downstream consumer impact.
Verdict: APPROVE ✅
No P0 or P1 issues found. The fix is well-designed, handles edge cases properly, and includes multiple safety improvements over the original (--force-with-lease, concurrency group, conflict detection, no-op skip). The PR description is thorough and accurately explains both the problem and the solution.
Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack
|
✅ Approved by Claude, approved by Codex — fixes the sync-main-to-experimental workflow to build Checked: merge logic for both fresh-branch and existing-PR paths converges to the correct experimental+main tree; Cross-model review by Jarvis 🤖 · head 5ece7fe · Claude + Codex · Requested by Charly (<@U0747ARK5TM>) via Slack |
|
The diagnosis is right and I confirmed the two git facts it rests on: Three things to settle before merging. P1 — after a conflict the workflow can never open the PR
Either push the conflicted state to a side ref, or spell out all four recovery steps in the annotation. P1 — the no-op gate suppresses the push, not just the create (L71-75)
The check exists to dodge P1 (precondition, not a defect in this diff) — the repo is squash-only as of today
Real merge commits are what keep P3 — non-blocking
Checked and cleared
SimplicityOver-built by roughly ten lines, and the excess is where the bugs cluster — the reuse-existing-branch machinery accounts for most of the findings above. Keying "rebuild vs. keep" on whether the remote branch already contains |
a364e95
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review: fix: sync-main-to-experimental action correct base branch
Files changed: 2 — .github/workflows/sync-main-to-experimental.yml (+83 −6), .github/workflows/merge-sync-to-experimental.yml (new, +67)
CI: Pending (check_and_build, Validate compatibility — both in progress)
ADR-6: PR title (fix: …) and branch (fix/sync-main-to-experimental-action) follow semantic conventions ✅
Head: a364e95 (includes "mini refactor based on PR feedback")
What changed since the last review (5ece7fe → a364e95)
The latest commit addresses the two code-level P1s from @charly-bg's review:
| charly-bg finding | Status |
|---|---|
| P1 — conflict can never open the PR (step-output gating + incomplete error message) | ✅ Fixed — step-output gating removed; PR step always runs; ::error:: now includes full recovery instructions |
P1 — no-op gate suppresses push, not just PR create (git diff --quiet gated both) |
✅ Fixed — push gate now compares HEAD vs origin/chore/sync; PR-create gate uses merge-base --is-ancestor HEAD origin/experimental |
| P1 — repo is squash-only (precondition) | ℹ️ Precondition, not a code defect — PR body now includes explicit squash warning + recovery instructions |
Additional cleanup: issues: write removed, --no-edit removed, --set-upstream removed, --force-with-lease claim dropped, branch-reuse decoupled from PR existence (just checks branch existence).
Analysis of current state
✅ Core merge logic
The fundamental fix is correct: branching from origin/experimental (or reusing origin/chore/sync) and merging origin/main into it preserves experimental-only content. The for-loop approach handles both fresh and reuse paths uniformly.
✅ Concurrency & race protection
concurrencygroups withcancel-in-progress: falseserialize pushes tomaincorrectly.--force-with-leasecorrectly rejects the push if a human pushed tochore/syncbetween fetch and push (verified: the tracking ref from the fetch is the lease comparand).
✅ Push & PR gate separation
- Push decision:
HEADvsREMOTE_HEAD— pushes whenever the recomputed branch differs from the remote. This fixes charly's scenario (main reverts → stale remote stays). ✅ - PR-create decision:
merge-base --is-ancestor HEAD origin/experimental— skips PR creation when experimental already contains everything. ✅
✅ Security
- No injection vectors: All dynamic values are either hardcoded strings or integers from
ghAPI. No untrusted input flows into shell commands. - Permissions minimal:
contents: write+pull-requests: write(sync),contents: write+pull-requests: read(merge). All justified. - Triggers safe:
pushonmain(requires repo write access) andworkflow_dispatch(same). Nopull_request_targetor other external-triggerable events. - No secrets exposure.
✅ Conflict handling
merge --abort || true + expanded ::error:: with four recovery steps + exit 1. Clean, nothing pushed on conflict. The recovery instructions correctly cover both the "branch exists" and "branch doesn't exist" cases.
Remaining findings (all P2)
[P2] Merge workflow no-op path skips cleanup
merge-sync-to-experimental.yml: when --is-ancestor origin/chore/sync origin/experimental is true (already merged), the workflow exits 0 without deleting chore/sync or affecting the PR. This is reachable after a partial failure (experimental push succeeds, branch delete fails in a prior run) or an external merge. Re-running the merge workflow always hits the same no-op exit.
Self-heals on next meaningful push to main (sync workflow recomputes and pushes). Manual fix is trivial (git push origin --delete chore/sync). Consider adding git push origin --delete chore/sync || true to the no-op path for robustness.
[P2] Redundant git config --replace-all in both workflows
With fetch-depth: 0, actions/checkout@v4 already sets remote.origin.fetch to the wildcard refspec via getRefSpecForAllHistory. The git config --replace-all line is dead code (as charly noted). The git fetch that follows is useful (refreshes refs), but the config line can be removed. Harmless, but noise.
[P2] Empty-diff PR lingers after net-zero main changes
If main adds then reverts a change while a sync PR is open, chore/sync accumulates merge commits not in experimental's graph even though the tree is identical. --is-ancestor (commit-graph-based) returns false, so the PR stays open with 0 files changed. Merging it creates an empty merge commit on experimental — harmless but noisy. A supplementary git diff --quiet origin/experimental HEAD check could catch this and skip PR maintenance.
Verdict: APPROVE ✅
No P0 or P1 issues. The latest commit substantively addresses both code-level P1s from the prior review. The push/PR gate separation is correct, conflict recovery is well-documented, and the two-workflow design (sync creates the branch, merge lands it as a real merge commit) is sound for working around the squash-only constraint. The P2 items above are worth addressing but do not block merge.
Note: charly's third P1 (squash-only repo setting) is a precondition — worth confirming merge commits are re-enabled or that the team is committed to using the merge workflow exclusively, but not a defect in this diff.
Reviewed by Jarvis 🤖 · Requested by Pravus (<@UDJQDQC0Z>) via Slack
d80fdb7
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-review (round 3): fix: sync-main-to-experimental action correct base branch
Head: d80fdb7 ("tackled review feedback")
CI: All checks passing ✅ (check_and_build, Validate compatibility, Deployment Notification)
ADR-6: ✅
Changes since last review (a364e95 → d80fdb7)
All three P2 findings from my prior review are addressed:
| P2 finding | Status |
|---|---|
| Merge workflow no-op path skips cleanup | ✅ Fixed — git push origin --delete chore/sync || true added to the --is-ancestor exit path |
Redundant git config --replace-all in both workflows |
✅ Fixed — removed from both files |
| Empty-diff PR lingers after net-zero main changes | ✅ Fixed — PR-create guard now combines --is-ancestor with git diff --quiet origin/experimental HEAD |
Verification of new logic
Merge workflow cleanup (L46-48):
if git merge-base --is-ancestor origin/chore/sync origin/experimental; then
echo "experimental already contains chore/sync; nothing to merge."
git push origin --delete chore/sync || true
exit 0
fiCorrect. The || true handles the case where the branch is already gone. Recovers from partial failures (prior run pushed experimental but failed on branch delete).
Sync workflow tree check (L94-98):
if git merge-base --is-ancestor HEAD origin/experimental \
|| git diff --quiet origin/experimental HEAD; then
echo "experimental already has everything chore/sync would propose."
exit 0
fiCorrect. First condition catches commit-graph containment. Second condition catches tree-identical-but-different-history (the net-zero revert scenario). || means either skips PR creation/update. The push step still runs independently, so the remote chore/sync branch is always up to date.
git config --replace-all removal: Both workflows now rely on actions/checkout@v4 with fetch-depth: 0 to set the wildcard refspec, followed by git fetch --no-tags --force --prune origin to refresh refs. Correct — the explicit config was redundant.
Verdict: APPROVE ✅
No P0 or P1 issues. All prior P2s resolved. CI green. The workflow logic is sound:
- Push gate (HEAD vs remote chore/sync) ensures the remote branch is always current
- PR-create gate (commit-graph + tree-diff) avoids both empty PRs and
gh pr createfailures - Merge workflow cleans up stale branches on both the happy path and the already-merged path
- Conflict recovery instructions are comprehensive
- Concurrency groups,
--force-with-lease, andset -euo pipefailprovide proper safety
Reviewed by Jarvis 🤖 · Requested by Pravus (<@UDJQDQC0Z>) via Slack
fix: sync
mainintoexperimentalas a real mergeProblem
The sync job reset
chore/synctomain's HEAD instead of merging intoexperimental, so the branch — and the@dcl/protocolpackage built from it — carried none ofexperimental's content. Regenerating in the Explorer against that package deleted experimental-only schema, e.g.public enum AvatarEmoteMaskinAvatarShape.gen.cs.Squashing the sync PR breaks it a second way:
mainstops being an ancestor ofexperimental, the merge base freezes, and later syncs conflict on contentexperimentalalready has.Changes
sync-main-to-experimental.yml— buildschore/syncasexperimental+mainfetch-depth: 0and a git identity, so the merges work at allexperimental, or reuses an in-flightchore/syncso a manual conflict resolution survives later pushes tomainexperimentaland a differing tree, so a net-zero round ofmainchanges cannot leave an empty PR behindmerge-sync-to-experimental.yml— new, manual (workflow_dispatch), lands the sync as a merge commitallow_merge_commit=falsemeans the PR button can only squash. That setting governs the button, notgit push, andexperimentalis unprotected — so the workflow mergeschore/syncintoexperimentalwith--no-ff, pushes, and deletes the branch. The repo setting staysfalse; no more flipping it on and off to land these.Full cycle
main.chore/syncand opens the sync PR intoexperimental.build-deploypublishes the tarball and comments the install URL;validate-compatibilityruns.main→ they are merged onto the samechore/sync, so the open PR just accumulates them.experimental, then the workflow deleteschore/sync.mainstarts a fresh cycle fromexperimental.Step 6's deletion matters: the sync workflow reads "
chore/syncexists" as "a sync is still in flight". GitHub'sdelete_branch_on_mergeonly fires for button merges, not for a PR closed by a push, so the workflow does it explicitly — including on its already-merged no-op path, so a run whose cleanup failed can be re-run to finish the job.Known residual (accepted)
If
mainadds and then reverts a change while a sync PR is already open, that PR stays open showing 0 files changed until the next real commit tomainrefills it, and merging it in that state records an empty merge commit. The branch is still pushed and the PR stops being commented on, so nothing goes stale or wrong — it is only noise, and it clears itself. Closing the PR automatically was considered and rejected: it would churn a PR that is about to become valid again, and landing those commits keeps the two histories linked.If it gets squashed anyway
Nothing blocks the button. Restore the ancestry with a no-content merge that records
mainas a parent:git checkout experimental git merge -s ours origin/main -m "chore: record main ancestry" git push origin experimental